package buildinjava.java;

import buildinjava.BuildException;
import buildinjava.io.Files;
import buildinjava.io.FileFilters;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileFilter;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Copies files from/to JAR files.
 *
 * @author Gili Tzabari
 */
public final class Jar
{
	private final Logger log = LoggerFactory.getLogger(Jar.class);
	private FileFilter toAdd;
	private FileFilter directories;
	private File baseDirectory;

	/**
	 * Creates a new Jar operation.
	 */
	public Jar()
	{
		this.toAdd = FileFilters.acceptAll();
		this.directories = FileFilters.intersection(toAdd, new FileFilter()
		{
			@Override
			public boolean accept(File candidate)
			{
				return candidate.isDirectory();
			}
		});
	}

	/**
	 * Indicates what files should be processed.
	 *
	 * @param files indicates what files or directories should be added. By default all files are included.
	 * @return the Jar object
	 * @throws IllegalArgumentException if files is null
	 */
	public Jar filter(FileFilter files) throws IllegalArgumentException
	{
		if (files == null)
			throw new IllegalArgumentException("files may not be null");
		this.toAdd = files;
		this.directories = FileFilters.intersection(toAdd, new FileFilter()
		{
			@Override
			public boolean accept(File candidate)
			{
				return candidate.isDirectory();
			}
		});
		return this;
	}

	/**
	 * Indicates the directory relative to which source files should be resolved.
	 * 
	 * @param baseDirectory the directory relative to which source files should be resolved
	 * @return the Jar object
	 */
	public Jar relativeTo(File baseDirectory)
	{
		if (!baseDirectory.isDirectory())
			throw new IllegalArgumentException("baseDirectory must be a directory: " + baseDirectory);
		this.baseDirectory = baseDirectory;
		return this;
	}

	/**
	 * Creates a new JAR file.
	 *
	 * @param source the file or directory to copy into the JAR file, implicitly accepted by the filter
	 * @param jarFile the JAR file
	 * @throws IllegalArgumentException if target is not a JAR file
	 * @throws BuildException if the operation fails
	 */
	public void create(final File source, File jarFile) throws BuildException
	{
		if (!jarFile.getName().endsWith(".jar"))
			throw new IllegalArgumentException("target must be a JAR file: " + jarFile.getPath());
		FileFilter implicitFilter = FileFilters.union(toAdd, new FileFilter()
		{
			@Override
			public boolean accept(File candidate)
			{
				return candidate.equals(source);
			}
		});
		if (log.isDebugEnabled())
			log.debug(toString());
		try
		{
			File sourcePath;
			if (baseDirectory == null)
			{
				if (source.isDirectory())
					sourcePath = source;
				else
					sourcePath = source.getParentFile();
			}
			else
				sourcePath = baseDirectory;
			if (jarFile.exists())
			{
				try
				{
					JarFile jarReader = new JarFile(jarFile);
					try
					{
						if (!changed(source, sourcePath, jarReader, jarFile.lastModified(), implicitFilter))
							return;
					}
					finally
					{
						jarReader.close();
					}
				}
				catch (IOException e)
				{
					throw new BuildException(jarFile.getAbsolutePath(), e);
				}
			}
			Manifest manifest = new Manifest();
			manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
			JarOutputStream jar = new JarOutputStream(new FileOutputStream(jarFile), manifest);
			add(source, sourcePath, jar, implicitFilter);
			jar.close();
		}
		catch (IOException e)
		{
			throw new BuildException(e);
		}
	}

	/**
	 * Indicates if the file associated with a jar entry has changed.
	 *
	 * @param source the file to copy from
	 * @param sourcePath the base directory for <code>source</code> paths
	 * @param jarFile the jar file being updated
	 * @param jarLastModified the time the jar file was last modified
	 * @param toAdd indicates what files to process
	 * @throws BuildException if an expected build error occurs
	 * @return true if the source file is newer than the jar file
	 */
	private boolean changed(File source, File sourcePath, JarFile jarFile, long jarLastModified,
													FileFilter toAdd)
		throws BuildException
	{
		try
		{
			String name = Files.getRelativeFile(source, sourcePath).getPath().replace("\\", "/");
			if (source.isDirectory())
			{
				for (File file: Files.list(source, directories, toAdd))
				{
					if (changed(file, sourcePath, jarFile, jarLastModified, toAdd))
					{
						log.trace("File changed: " + file);
						return true;
					}
				}
				return false;
			}
			ZipEntry entry = jarFile.getEntry(name);
			if (entry == null)
				return true;
			return source.lastModified() > jarLastModified;
		}
		catch (FileNotFoundException e)
		{
			throw new BuildException(e);
		}
		catch (IOException e)
		{
			throw new BuildException(e);
		}
	}

	/**
	 * Adds a file or directory into the jar file.
	 *
	 * @param source the file or directory to add
	 * @param workingDirectory the working directory to add files from
	 * @param jar the jar stream to write into
	 * @param toAdd indicates what files to process
	 * @throws BuildException if an expected build error occurs
	 */
	private void add(File source, File workingDirectory, JarOutputStream target, FileFilter toAdd)
		throws BuildException
	{
		BufferedInputStream in = null;
		try
		{
			String name = Files.getRelativeFile(source, workingDirectory).getPath().replace("\\", "/");
			if (source.isDirectory())
			{
				if (!name.isEmpty())
				{
					if (!name.endsWith("/"))
						name += "/";
					JarEntry entry = new JarEntry(name);
					entry.setTime(source.lastModified());
					target.putNextEntry(entry);
					target.closeEntry();
				}
				for (File file: source.listFiles(toAdd))
					add(file, workingDirectory, target, toAdd);
				return;
			}

			if (log.isTraceEnabled())
				log.trace("Adding " + source.getPath() + " as " + name);

			JarEntry entry = new JarEntry(name);
			entry.setTime(source.lastModified());
			target.putNextEntry(entry);
			if (!source.exists())
				throw new BuildException(source.getAbsolutePath() + " does not exist");
			in = new BufferedInputStream(new FileInputStream(source));

			byte[] buffer = new byte[1024];
			while (true)
			{
				int count = in.read(buffer);
				if (count == -1)
					break;
				target.write(buffer, 0, count);
			}
			target.closeEntry();
		}
		catch (IOException e)
		{
			throw new BuildException(e);
		}
		finally
		{
			try
			{
				if (in != null)
					in.close();
			}
			catch (IOException e)
			{
				throw new BuildException(e);
			}
		}
	}

	@Override
	public String toString()
	{
		return getClass().getName() + "[toAdd=" + toAdd + "]";
	}
}
